1"use client";
2
3
25import { useState } from "react";
26import { useParams } from "next/navigation";
27import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
28import { Plus } from "lucide-react";
29import { toast } from "@/stores/ui-store";
30import { apiFetch } from "@/lib/api";
31import { API_BASE } from "@/lib/constants";
32import { useWorkspace } from "@/hooks/use-workspace";
33import { SettingsShell, Section } from "@/components/settings/settings-shell";
34import { ApiKeyCreateDialog } from "@/components/settings/api-keys/ApiKeyCreateDialog";
35import { ApiKeyRevealDialog } from "@/components/settings/api-keys/ApiKeyRevealDialog";
36import { ApiKeyTable, type ApiKeyRow } from "@/components/settings/api-keys/ApiKeyTable";
37import { OverviewTab } from "@/components/settings/webhooks/OverviewTab";
38import { WebhooksTab } from "@/components/settings/webhooks/WebhooksTab";
39import { EventExplorer } from "@/components/settings/webhooks/EventExplorer";
40import { CurlExamples } from "@/components/settings/webhooks/CurlExamples";
41import { cn } from "@/lib/utils";
42
43type Tab = "overview" | "keys" | "webhooks" | "events" | "curl";
44
45
46function unwrap<T>(body: unknown): T[] {
47 const b = body as { data?: T[] } | T[] | null;
48 if (Array.isArray(b)) return b;
49 if (b && typeof b === "object" && Array.isArray((b as { data?: T[] }).data))
50 return (b as { data: T[] }).data;
51 return [];
52}
53
54export default function SettingsDevelopersPage() {
55 const params = useParams<{ workspace: string }>();
56 const slugOrId = params?.workspace ?? "";
57 const { data: ws, isLoading } = useWorkspace(slugOrId);
58 const wid = ws?.id ?? "";
59 const [tab, setTab] = useState<Tab>("keys");
60
61
62 const role = (ws?.role ?? "").toLowerCase();
63 const isAdminRole = role === "owner" || role === "admin";
64
65 return (
66 <SettingsShell
67 title="Developers"
68 description="Integrate with the Factory API: quickstart, keys, webhooks, events and curl examples."
69 active="developers"
70 >
71 {isLoading || !ws ? (
72 <div className="space-y-3" aria-label="Loading developers settings">
73 {[0, 1, 2].map((i) => (
74 <div key={i} className="h-24 animate-pulse rounded-lg bg-muted/60" />
75 ))}
76 </div>
77 ) : !isAdminRole ? (
78
79
80 <Section
81 title="Owner or Admin required"
82 description="Creating, listing and revoking API keys requires the Owner or Admin role in this workspace."
83 >
84 <p className="text-sm text-muted-foreground">
85 Ask a workspace owner to manage API keys for you, or request the Admin role.
86 </p>
87 </Section>
88 ) : (
89 <div className="space-y-5">
90 <div
91 role="tablist"
92 aria-label="Developers sections"
93 className="flex gap-1 border-b border-border"
94 >
95 {(
96 [
97 { id: "overview", label: "Overview" },
98 { id: "keys", label: "API keys" },
99 { id: "webhooks", label: "Webhooks" },
100 { id: "events", label: "Events" },
101 { id: "curl", label: "Curl examples" },
102 ] as { id: Tab; label: string }[]
103 ).map((t) => (
104 <button
105 key={t.id}
106 role="tab"
107 aria-selected={tab === t.id}
108 type="button"
109 onClick={() => setTab(t.id)}
110 className={cn(
111 "rounded-t-md px-3 py-2 text-sm",
112 tab === t.id
113 ? "border-b-2 border-primary font-medium text-foreground"
114 : "text-muted-foreground hover:text-foreground",
115 )}
116 >
117 {t.label}
118 </button>
119 ))}
120 </div>
121 {tab === "overview" ? <OverviewTab workspaceSlug={slugOrId} /> : null}
122 {tab === "keys" ? <ApiKeys workspaceId={wid} /> : null}
123 {tab === "webhooks" ? <WebhooksTab workspaceId={wid} /> : null}
124 {tab === "events" ? (
125 <Section
126 title="Event catalog"
127 description="Every event a webhook can subscribe to — the shared catalog drives the dispatcher, the API and this explorer."
128 >
129 <EventExplorer />
130 </Section>
131 ) : null}
132 {tab === "curl" ? <CurlExamples workspaceSlug={slugOrId} /> : null}
133 </div>
134 )}
135 </SettingsShell>
136 );
137}
138
139
140
141function ApiKeys({ workspaceId }: { workspaceId: string }) {
142 const qc = useQueryClient();
143 const [createOpen, setCreateOpen] = useState(false);
144 const [reveal, setReveal] = useState<{ rawKey: string; name: string } | null>(null);
145 const [revokingId, setRevokingId] = useState<string | null>(null);
146
147 const keys = useQuery({
148 queryKey: ["workspaces", workspaceId, "api-keys"],
149 queryFn: async (): Promise<ApiKeyRow[]> =>
150 unwrap<ApiKeyRow>(await apiFetch(`/workspaces/${workspaceId}/api-keys`)),
151 enabled: !!workspaceId,
152 });
153
154 const create = useMutation({
155 mutationFn: async (input: {
156 name: string;
157 scopes: string[];
158 rateLimit?: number;
159 expiresAt?: string;
160 }) => {
161 const res = await apiFetch<{ key: string }>(`/workspaces/${workspaceId}/api-keys`, {
162 method: "POST",
163 body: input,
164 });
165 return res;
166 },
167 onSuccess: () => qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "api-keys"] }),
168 });
169
170 const revoke = useMutation({
171 mutationFn: (id: string) =>
172 apiFetch(`/workspaces/${workspaceId}/api-keys/${id}`, { method: "DELETE" }),
173 onMutate: (id) => setRevokingId(id),
174 onSettled: () => setRevokingId(null),
175 onSuccess: () => qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "api-keys"] }),
176 onError: (e) => toast("error", (e as Error).message || "Could not revoke the key."),
177 });
178
179
180
181 const baseApiUrl = API_BASE;
182
183 return (
184 <Section
185 title="API keys"
186 description="Authenticate server-to-server calls. The raw key is shown once, at creation."
187 >
188 <div className="mb-3 flex justify-end">
189 <button
190 type="button"
191 onClick={() => setCreateOpen(true)}
192 className="inline-flex items-center gap-1 rounded-md bg-primary px-2.5 py-1.5 text-sm font-medium text-white hover:opacity-90"
193 data-testid="apikey-create-open"
194 >
195 <Plus className="size-3.5" aria-hidden />
196 Create API key
197 </button>
198 </div>
199
200 <ApiKeyTable
201 rows={keys.data ?? []}
202 isLoading={keys.isLoading}
203 baseApiUrl={baseApiUrl}
204 workspaceId={workspaceId}
205 onRevoke={(row) => revoke.mutate(row.id)}
206 revokingId={revokingId}
207 onCreate={() => setCreateOpen(true)}
208 />
209
210 <ApiKeyCreateDialog
211 open={createOpen}
212 onClose={() => setCreateOpen(false)}
213 onCreated={(k) => {
214 setCreateOpen(false);
215 setReveal(k);
216 }}
217 create={create.mutateAsync}
218 isPending={create.isPending}
219 />
220
221 {reveal ? (
222 <ApiKeyRevealDialog
223 open
224 rawKey={reveal.rawKey}
225 keyName={reveal.name}
226 onConfirmed={() => setReveal(null)}
227 />
228 ) : null}
229 </Section>
230 );
231}
232